Skip to content

FileSink: keep the event loop alive until end() has drained a pipe - #41709

Merged
Jarred-Sumner merged 3 commits into
mainfrom
robobun/a9c8b652/filesink-end-keeps-loop-alive
Sep 7, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
robobun/a9c8b652/filesink-end-keeps-loop-alive

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A pipe-backed FileSink (Bun.stdout.writer(), Bun.file(fifo).writer(), Bun.spawn stdin) loses data when write() goes async and the script does not await end(). bun a.mjs | (sleep 1; wc -c) with a 4 MiB chunk delivers 65536 bytes. The process exits with code 0 a few ms later. An await end() inside an async function (not top level) loses the data the same way.
  • The cause is on_auto_flush (src/runtime/webcore/FileSink.rs:855). write() enabled the writable poll's ref on the loop for the buffered tail. end() took a short flush, set done, and returned the pending promise. The deferred auto-flush task then saw done and called update_ref(false). Nothing held the loop, so it exited before the poll could drain the tail.

Fix

  • on_auto_flush releases the poll's ref only when the buffer is empty. When done is set with bytes still buffered, it unregisters itself and leaves the ref in place. The writable poll drains the tail. on_write releases the ref once the buffer is empty, then end_writer closes the sink and settles the promise.
  • Correct because an accepted byte that has not reached the fd is active work. write() without end() already kept the loop alive for the same bytes. Node keeps the process alive for a pending stdout.write too.
  • Verified: test/js/bun/util/filesink.test.ts (three new tests, all fail on 1.4.3: 694336 and 438528 of 4194304 bytes, and an empty count from the unref'd child). Also the rest of that file, bun-write.test.js, the spawn stdin suites, spawn.test.ts, child_process.test.ts, process-stdio.test.ts.

Background

  • FileSink is the native writer behind Blob.writer() and a subprocess stdin pipe. It owns a StreamingWriter with a byte buffer and, on a pipe or socket, a FilePoll registered for writability.
  • FilePoll::enable_keeping_process_alive bumps the uws loop's active count. The loop exits when that count and the task queues are all zero. update_ref(bool) on the sink turns this on or off.
  • on_write runs after each drain. It sets the ref from has_pending_data(). The AutoFlusher runs on_auto_flush as a deferred task after the current tick, to flush small buffered writes without a poll wake.
Notes
  • write() alone, with no end(), delivers all 4 MiB on 1.4.3. Only the end() path lost data, which pointed at the done check.
  • The FIFO case (Bun.file(fifo).writer()) delivered 3489792 of 4194304 on 1.4.3 and 4194304 with the fix.
  • Bun.spawn stdin with unref(): on 1.4.3 the count depends on how fast the child reads. A wc -c that reads from the start often gets all 4 MiB because the first synchronous write loop keeps up. The test makes the child wait until end() was called before it reads, so the pipe is full and the result is stable.
  • Windows shares on_auto_flush. There has_pending_data() also covers a uv_write in flight, so the ref now stays until the write callback runs.
  • Pre-existing failures on main in this container, unrelated to this change: bun-write.test.js "copyFileRange is not available > on large files" (5 s timeout in debug), process-stdio.test.ts the three process.stdin tests, child_process.test.ts "spawn in the default shell" and "extra stdio pipes are not double-closed on GC".

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/filesink.test.ts

A chunk larger than the pipe buffer leaves its tail in the sink's buffer.
end() after that takes a short flush and sets done. The deferred auto-flush
task then released the writable poll's ref on the loop because done was
set, and the process exited with the tail unwritten.

Release the ref only when the buffer is empty. When done is set with bytes
still buffered, the writable poll drains them and holds the loop until then.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e70191e9-21c9-4c5c-951b-34c30b3bc0e7

📥 Commits

Reviewing files that changed from the base of the PR and between d316760 and ce4578f.

📒 Files selected for processing (2)
  • src/runtime/webcore/FileSink.rs
  • test/js/bun/util/filesink.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:46 AM PT - Sep 6th, 2026

⏳ @robobun, your commit ce4578f is still building in Build #111650, but has 2 failures so far (All Failures):

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/util/filesink.test.ts Outdated
…or the flag

On Windows uv_write takes the whole chunk, so end() returns a number and
.then() threw. The stdin test's reader now gives up after a deadline, and
the parent writes the flag from a finally block, so the reader cannot be
orphaned by a parent that threw.
Comment thread src/runtime/webcore/FileSink.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3 (Linux x64) with bun a.mjs | (sleep 1; wc -c), where a.mjs is const w = Bun.stdout.writer(); w.write(Buffer.alloc(4 << 20, 46)); w.end();. The reader gets 65536 to 800K of 4194304 bytes and bun exits with code 0. The same happens for Bun.file(fifo).writer() (3489792 of 4194304) and for await w.end() inside an async function. With this branch all three deliver 4194304.

CI, build #111650: test/js/bun/util/filesink.test.ts passes on every Linux lane (x64, aarch64, musl, ASAN), on both Windows lanes, and on macOS aarch64. The two macOS x64 test jobs expired in the queue without running (Intel macOS backlog), so they report neither pass nor fail. Two red tests do not touch this diff:

  • test/js/node/test/parallel/test-crypto-dh-leak.js on x64-asan: RSS assertion, pre-existing on main and red on most PR builds today.
  • test/cli/run/multi-run.test.ts on Windows aarch64: EBUSY removing the temp dir right after the test SIGKILLs its detached child. No FileSink is involved.

The diff is ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit 3f2610e into main Sep 7, 2026
9 of 10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/a9c8b652/filesink-end-keeps-loop-alive branch September 7, 2026 07:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants